Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

11장. 배열과 슬라이스

지금까지 다룬 변수는 값 하나만 담는 그릇이었다. 이번 장부터는 여러 값을 묶어서 다루는 방법을 배운다.

Go 에는 비슷해 보이는 두 형제가 있다.

  • 배열 (array) — 고정 길이
  • 슬라이스 (slice) — 가변 길이

이론상 둘 다 “값을 줄 세워 담는다” 는 점은 같다. 하지만 실제로 Go 개발자가 거의 매일 쓰는 건 슬라이스 쪽이다. 배열은 그 슬라이스를 이해하기 위한 발판으로 짧게 짚고 넘어간다.

목표:

  • 배열과 슬라이스가 어떻게 다른지 한 문장으로 말하기
  • 슬라이스를 자유롭게 만들고, 늘리고, 자르기
  • lencap 의 차이 이해하기
  • append, copy, for range 를 손에 익히기

11.1 배열

배열은 같은 타입의 값을 정해진 개수만큼 한 줄로 담는 자료형이다.

var a [5]int

이 한 줄은 “정수 5개를 담는 그릇 a” 를 만든다. 값을 따로 넣어 주지 않았기 때문에 다섯 칸 모두 정수의 제로값 0 으로 채워진다.

인덱스로 접근

각 칸은 0번부터 시작하는 인덱스로 가리킨다.

a[0] = 10
a[4] = 99
fmt.Println(a)      // [10 0 0 0 99]
fmt.Println(a[0])   // 10

범위 밖 인덱스를 쓰면 컴파일 또는 런타임 에러가 난다.

fmt.Println(a[5])  // panic: index out of range

길이가 타입의 일부다

여기가 배열의 가장 중요한 특징이자 함정이다.

var a [5]int
var b [10]int

ab 는 둘 다 정수 배열이지만, 타입이 서로 다르다.

  • a 의 타입은 [5]int
  • b 의 타입은 [10]int

대입도 불가능하다.

a = b  // 컴파일 에러: cannot use b (type [10]int) as type [5]int

함수에 넘길 때도 길이가 정확히 맞아야 한다. “정수 배열을 받는 함수” 가 아니라 “길이 5짜리 정수 배열을 받는 함수” 가 되는 셈이다.

이게 너무 빡빡하기 때문에 실무에서 배열을 직접 쓰는 일은 거의 없다.

배열 리터럴

값을 미리 정해서 만들 수도 있다.

nums := [3]int{1, 2, 3}

길이를 일일이 세기 귀찮다면 ... 을 써서 컴파일러에게 맡긴다.

nums := [...]int{10, 20, 30, 40}
fmt.Println(len(nums))  // 4

[...]int 는 “길이는 알아서 세 줘” 라는 뜻이다. 결과 타입은 여전히 [4]int 처럼 길이가 박힌 배열이다.

배열은 값이다

마지막으로 알아 둘 점. 배열을 다른 변수에 대입하면 통째로 복사된다.

a := [3]int{1, 2, 3}
b := a
b[0] = 999

fmt.Println(a)  // [1 2 3]
fmt.Println(b)  // [999 2 3]

b 를 바꿔도 a 는 그대로다. 이 동작은 슬라이스와 다르므로 잘 기억해 둔다.

배열에 대해선 이 정도면 충분하다. 이제 Go 의 주력 자료구조인 슬라이스로 넘어간다.


11.2 슬라이스: Go 의 주력 자료구조

슬라이스는 길이가 자유롭게 변하는 시퀀스다. 다른 언어의 동적 배열, ArrayList, 리스트와 비슷하다.

선언과 초기화

가장 흔한 세 가지 방법.

// 1. 리터럴로 바로 만들기
nums := []int{1, 2, 3}

// 2. make 로 길이만 지정해서 만들기
zeros := make([]int, 5)
// → [0 0 0 0 0]

// 3. make 로 길이와 용량 함께 지정
buf := make([]int, 0, 10)
// 길이 0, 용량 10

배열과 어떻게 다른지 한눈에 보이는 차이는 대괄호 안에 숫자가 없다는 점이다.

[5]int  // 배열: 길이 5
[]int   // 슬라이스: 길이 정해지지 않음

슬라이스의 내부 구조

슬라이스는 마법이 아니다. 내부적으로는 세 개의 값을 묶은 작은 구조다.

필드의미
포인터실제 데이터가 들어 있는 배열의 시작 위치
길이 (len)지금 보이는 원소의 개수
용량 (cap)다시 할당 없이 늘릴 수 있는 최대 개수
슬라이스 ──▶ [ * ] [ 3 ] [ 5 ]
              │
              ▼
            [ 1, 2, 3, _, _ ]   ← 실제 배열

즉, 슬라이스 그 자체는 “데이터를 가리키는 작은 손잡이” 다. 값을 담고 있는 진짜 배열은 따로 있다.

이 모델만 머릿속에 그려 두면 앞으로 나오는 len, cap, append, 슬라이싱이 자연스럽게 이해된다.


11.3 len 과 cap

내장 함수 두 개로 슬라이스의 상태를 들여다본다.

s := make([]int, 3, 10)

fmt.Println(len(s))  // 3
fmt.Println(cap(s))  // 10
  • len(s) — 지금 사용 중인 원소 수
  • cap(s) — 새 배열을 만들지 않고 담을 수 있는 최대치

처음엔 헷갈리기 쉬우니 비유로 본다.

len 은 가방 안에 든 책 권수 cap 은 가방 안에 들어갈 수 있는 최대 권수

가방이 꽉 차기 전까진 책을 더 넣을 수 있다. 넘치면 더 큰 가방으로 옮겨야 한다. 이 “옮기는 동작” 이 다음 절의 append 에서 일어난다.

만든 방식lencap
[]int{1,2,3}33
make([]int, 5)55
make([]int, 0, 10)010
make([]int, 3, 10)310

11.4 append: 원소 추가

슬라이스에 새 값을 더할 땐 내장 함수 append 를 쓴다.

s := []int{1, 2, 3}
s = append(s, 4)
fmt.Println(s)  // [1 2 3 4]

겉으로는 단순하지만, 내부에서는 두 가지 분기가 있다.

  1. 용량(cap)에 여유가 있다 → 같은 배열의 빈 칸에 그냥 채운다
  2. 용량이 부족하다 → 더 큰 배열을 새로 만들고 기존 값을 복사한 뒤 추가한다

그래서 append 는 반드시 반환값을 다시 받아야 한다.

// 옳은 사용
s = append(s, 4)

// 잘못된 사용 — 새 배열이 만들어졌다면 s 는 그대로 옛 데이터를 본다
append(s, 4)  // 컴파일러가 "값을 안 쓴다" 고 경고하기도 한다

여러 개를 한 번에

append 는 원소를 여러 개 받을 수 있다.

s := []int{1}
s = append(s, 2, 3, 4)
fmt.Println(s)  // [1 2 3 4]

슬라이스끼리 합치기

다른 슬라이스를 통째로 붙일 땐 ... 을 붙인다.

a := []int{1, 2, 3}
b := []int{4, 5, 6}

c := append(a, b...)
fmt.Println(c)  // [1 2 3 4 5 6]

b... 는 “b 의 원소들을 풀어서 인자로 넘긴다” 는 의미다. 9장의 가변 인자와 같은 문법이다.

append 의 성능 감각

append 가 새 배열을 만드는 일은 비용이 크다. 하지만 Go 는 한 번 늘릴 때 용량을 두 배 가까이 잡아 둔다. 그래서 평균적으로 보면 매우 빠르다.

미리 크기를 알 수 있다면 make([]T, 0, n) 으로 용량을 잡아 두는 것이 좋다. 자세한 메모리 이야기는 26장에서 다룬다.


11.5 슬라이싱

이미 있는 슬라이스(또는 배열)에서 “일부 구간만 잘라낸 슬라이스” 를 만들 수 있다.

s := []int{10, 20, 30, 40, 50}

mid := s[1:4]
fmt.Println(mid)  // [20 30 40]

s[1:4] 는 “인덱스 1부터 4 직전까지” 를 의미한다. 끝쪽 4는 포함되지 않는 점에 주의한다.

한쪽을 생략

양쪽 끝은 생략할 수 있다.

s[:3]   // 처음부터 인덱스 3 직전까지
s[2:]   // 인덱스 2부터 끝까지
s[:]    // 전체
표현결과
s[1:4][20 30 40]
s[:3][10 20 30]
s[2:][30 40 50]
s[:][10 20 30 40 50]

슬라이싱은 복사가 아니다

이게 슬라이스에서 가장 잘 모르고 지나가는 부분이다. 잘라낸 슬라이스는 원본과 같은 배열을 공유한다.

s := []int{1, 2, 3, 4, 5}
m := s[1:4]
m[0] = 999

fmt.Println(s)  // [1 999 3 4 5]
fmt.Println(m)  // [999 3 4]

m 을 건드렸을 뿐인데 s 까지 같이 바뀐 모습이 보인다. 손잡이만 두 개일 뿐, 안쪽 배열은 한 덩어리이기 때문이다.

이 동작은 강력하지만 메모리 측면에선 함정이 되기도 한다. “부분 슬라이스가 거대한 원본 배열을 붙잡고 놓아주지 않는” 패턴이 대표적이다. 자세한 이야기와 copy 로 끊어내는 기법은 26장에서 다룬다.


11.6 copy

두 슬라이스 사이에서 값을 복사할 땐 내장 함수 copy 를 쓴다.

src := []int{1, 2, 3}
dst := make([]int, 3)

n := copy(dst, src)
fmt.Println(dst)  // [1 2 3]
fmt.Println(n)    // 3

copy(dst, src)

  • dst 에 src 의 값을 복사한다
  • 실제로 복사된 개수를 반환한다

짧은 쪽 길이만큼만

두 슬라이스의 길이가 다르면, 짧은 쪽 길이만큼만 복사된다.

dst := make([]int, 2)
src := []int{1, 2, 3, 4, 5}

n := copy(dst, src)
fmt.Println(dst)  // [1 2]
fmt.Println(n)    // 2

이 동작 덕분에 버퍼 크기를 넘는 데이터를 잘라 받기 편하다.

append 와의 차이

도구하는 일
append슬라이스를 늘리며 값을 추가한다
copy기존 슬라이스의 칸에 값을 덮어쓴다

copy 는 dst 의 길이를 늘려 주지 않는다. 이미 있는 칸을 채울 뿐이다. “빈 슬라이스에 copy 했더니 아무 일도 안 일어났다” 는 초보의 단골 실수가 여기서 나온다.

var dst []int
src := []int{1, 2, 3}

copy(dst, src)
fmt.Println(dst)  // []  ← dst 의 길이가 0이라 아무것도 못 들어간다

이런 경우엔 append 를 써야 한다.


11.7 슬라이스 순회 (for range)

8장에서 살짝 본 for range 가 이제 본격적으로 빛난다.

인덱스와 값 둘 다

nums := []int{10, 20, 30}

for i, v := range nums {
    fmt.Println(i, v)
}
// 출력:
// 0 10
// 1 20
// 2 30

i 는 인덱스, v 는 그 인덱스의 값이다.

인덱스만 쓰고 싶을 때

값을 안 쓰면 그냥 두 번째 변수를 빼면 된다.

for i := range nums {
    fmt.Println(i)
}

값만 쓰고 싶을 때

인덱스를 안 쓸 땐 자리 표시자 _ 를 쓴다.

for _, v := range nums {
    fmt.Println(v)
}

_ 는 “값을 받기는 하지만 쓰지 않겠다” 는 명시적 표시다. Go 는 안 쓰는 변수를 컴파일 에러로 막기 때문에 이런 자리 표시자가 자주 등장한다.

range 가 주는 v 는 복사본이다

조금 미묘한 포인트.

nums := []int{1, 2, 3}

for _, v := range nums {
    v = v * 10  // 원본은 안 바뀐다
}
fmt.Println(nums)  // [1 2 3]

v 는 원소의 복사본이다. 원본을 바꾸려면 인덱스로 직접 접근해야 한다.

for i := range nums {
    nums[i] *= 10
}
fmt.Println(nums)  // [10 20 30]

11.8 nil 슬라이스

선언만 하고 초기화하지 않은 슬라이스는 어떻게 될까?

var s []int
fmt.Println(s == nil)  // true
fmt.Println(len(s))    // 0
fmt.Println(cap(s))    // 0

이 상태를 nil 슬라이스라고 한다.

  • 값은 nil
  • 길이도 0, 용량도 0
  • 안에 가리키는 배열이 아직 없는 상태

여기서 흥미로운 점.

nil 슬라이스에 append 해도 된다

var s []int

s = append(s, 1)
s = append(s, 2)
fmt.Println(s)  // [1 2]

append 는 nil 슬라이스를 받으면 새 배열을 알아서 만들어 첫 원소를 넣는다. 다른 언어처럼 “비어 있는 리스트인지 먼저 확인” 같은 방어 코드가 거의 필요하지 않다.

nil 슬라이스 vs 빈 슬라이스

비슷하지만 미묘하게 다르다.

var a []int           // nil 슬라이스
b := []int{}          // 빈 슬라이스 (nil 아님)

fmt.Println(a == nil) // true
fmt.Println(b == nil) // false
fmt.Println(len(a))   // 0
fmt.Println(len(b))   // 0

대부분의 코드에서는 둘이 똑같이 동작한다. 실용적으로는 “둘 다 길이 0인 슬라이스” 로 묶어서 이해하면 된다.

12장에서 다룰 맵의 nil 동작과는 다르다. 맵의 nil 은 함정이 많다.


11.9 정리

이 장에서 살펴본 내용:

  • 배열은 길이가 타입에 박힌 고정 시퀀스다
    • 잘 안 쓰이지만 슬라이스 이해의 발판
  • 슬라이스는 (포인터, 길이, 용량) 세 값을 가진 손잡이다
  • len 은 현재 길이, cap 은 재할당 없이 담을 수 있는 최대치
  • append 는 반환값을 반드시 다시 받아야 한다
  • 슬라이싱은 새 배열을 만드는 게 아니라 원본을 공유한다
  • copy 는 짧은 쪽 길이만큼만 복사한다
  • for range 로 인덱스, 값, 또는 둘 다 받을 수 있다
  • nil 슬라이스도 append 가 가능하다

슬라이스는 Go 코드의 기본 단위라고 봐도 좋다. 앞으로 거의 모든 챕터에서 다시 만나게 된다.

다음 장에서는 또 다른 핵심 자료구조인 을 다룬다. 키로 값을 찾는 자료구조가 어떻게 생겼는지 살펴본다.